chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from azure.ai.agents.models import Agent as AzureAIAgentModel
|
||||
from azure.ai.projects.aio import AIProjectClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ai_project_client() -> AsyncMock:
|
||||
client = AsyncMock(spec=AIProjectClient)
|
||||
|
||||
agents_mock = MagicMock()
|
||||
client.agents = agents_mock
|
||||
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ai_agent_definition() -> AsyncMock:
|
||||
definition = AsyncMock(spec=AzureAIAgentModel)
|
||||
definition.id = "agent123"
|
||||
definition.name = "agentName"
|
||||
definition.description = "desc"
|
||||
definition.instructions = "test agent"
|
||||
|
||||
return definition
|
||||
@@ -0,0 +1,464 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from azure.ai.agents.models import (
|
||||
MessageDelta,
|
||||
MessageDeltaChunk,
|
||||
MessageDeltaImageFileContent,
|
||||
MessageDeltaImageFileContentObject,
|
||||
MessageDeltaTextContent,
|
||||
MessageDeltaTextContentObject,
|
||||
MessageDeltaTextFileCitationAnnotation,
|
||||
MessageDeltaTextFileCitationAnnotationObject,
|
||||
MessageDeltaTextFilePathAnnotation,
|
||||
MessageDeltaTextFilePathAnnotationObject,
|
||||
MessageDeltaTextUrlCitationAnnotation,
|
||||
MessageDeltaTextUrlCitationDetails,
|
||||
MessageImageFileContent,
|
||||
MessageImageFileDetails,
|
||||
MessageTextContent,
|
||||
MessageTextDetails,
|
||||
MessageTextFileCitationAnnotation,
|
||||
MessageTextFileCitationDetails,
|
||||
MessageTextFilePathAnnotation,
|
||||
MessageTextFilePathDetails,
|
||||
MessageTextUrlCitationAnnotation,
|
||||
MessageTextUrlCitationDetails,
|
||||
RequiredFunctionToolCall,
|
||||
RunStep,
|
||||
RunStepBingCustomSearchToolCall,
|
||||
RunStepBingGroundingToolCall,
|
||||
RunStepDeltaFunction,
|
||||
RunStepDeltaFunctionToolCall,
|
||||
RunStepDeltaToolCallObject,
|
||||
RunStepFunctionToolCall,
|
||||
RunStepFunctionToolCallDetails,
|
||||
ThreadMessage,
|
||||
)
|
||||
|
||||
from semantic_kernel.agents.azure_ai.agent_content_generation import (
|
||||
THREAD_MESSAGE_ID,
|
||||
generate_annotation_content,
|
||||
generate_bing_grounding_content,
|
||||
generate_code_interpreter_content,
|
||||
generate_function_call_content,
|
||||
generate_function_result_content,
|
||||
generate_message_content,
|
||||
generate_streaming_annotation_content,
|
||||
generate_streaming_code_interpreter_content,
|
||||
generate_streaming_function_content,
|
||||
generate_streaming_message_content,
|
||||
get_function_call_contents,
|
||||
get_message_contents,
|
||||
)
|
||||
from semantic_kernel.contents.annotation_content import AnnotationContent
|
||||
from semantic_kernel.contents.chat_message_content import ChatMessageContent
|
||||
from semantic_kernel.contents.file_reference_content import FileReferenceContent
|
||||
from semantic_kernel.contents.function_call_content import FunctionCallContent
|
||||
from semantic_kernel.contents.function_result_content import FunctionResultContent
|
||||
from semantic_kernel.contents.image_content import ImageContent
|
||||
from semantic_kernel.contents.streaming_annotation_content import StreamingAnnotationContent
|
||||
from semantic_kernel.contents.streaming_file_reference_content import StreamingFileReferenceContent
|
||||
from semantic_kernel.contents.streaming_text_content import StreamingTextContent
|
||||
from semantic_kernel.contents.text_content import TextContent
|
||||
from semantic_kernel.contents.utils.author_role import AuthorRole
|
||||
|
||||
|
||||
def test_get_message_contents_all_types():
|
||||
chat_msg = ChatMessageContent(role=AuthorRole.USER, content="")
|
||||
chat_msg.items.append(TextContent(text="hello world"))
|
||||
chat_msg.items.append(ImageContent(uri="http://example.com/image.png"))
|
||||
chat_msg.items.append(FileReferenceContent(file_id="file123"))
|
||||
chat_msg.items.append(FunctionResultContent(id="func1", result={"a": 1}))
|
||||
results = get_message_contents(chat_msg)
|
||||
assert len(results) == 4
|
||||
assert results[0]["type"] == "text"
|
||||
assert results[1]["type"] == "image_url"
|
||||
assert results[2]["type"] == "image_file"
|
||||
assert results[3]["type"] == "text"
|
||||
|
||||
|
||||
def test_generate_message_content_text_and_image():
|
||||
thread_msg = ThreadMessage(
|
||||
content=[],
|
||||
role="user",
|
||||
)
|
||||
|
||||
image = MessageImageFileContent(image_file=MessageImageFileDetails(file_id="test_file_id"))
|
||||
|
||||
text = MessageTextContent(
|
||||
text=MessageTextDetails(
|
||||
value="some text",
|
||||
annotations=[
|
||||
MessageTextFileCitationAnnotation(
|
||||
text="text",
|
||||
file_citation=MessageTextFileCitationDetails(file_id="file_id", quote="some quote"),
|
||||
start_index=0,
|
||||
end_index=9,
|
||||
),
|
||||
MessageTextFilePathAnnotation(
|
||||
text="text again",
|
||||
file_path=MessageTextFilePathDetails(file_id="file_id_2"),
|
||||
start_index=1,
|
||||
end_index=10,
|
||||
),
|
||||
MessageTextUrlCitationAnnotation(
|
||||
text="text",
|
||||
url_citation=MessageTextUrlCitationDetails(title="some title", url="http://example.com"),
|
||||
start_index=1,
|
||||
end_index=10,
|
||||
),
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
thread_msg.content = [image, text]
|
||||
step = RunStep(id="step_id", run_id="run_id", thread_id="thread_id", agent_id="agent_id")
|
||||
out = generate_message_content("assistant", thread_msg, step)
|
||||
assert len(out.items) == 5
|
||||
assert isinstance(out.items[0], FileReferenceContent)
|
||||
assert isinstance(out.items[1], TextContent)
|
||||
assert isinstance(out.items[2], AnnotationContent)
|
||||
assert isinstance(out.items[3], AnnotationContent)
|
||||
assert isinstance(out.items[4], AnnotationContent)
|
||||
|
||||
assert out.items[0].file_id == "test_file_id"
|
||||
|
||||
assert out.items[1].text == "some text"
|
||||
|
||||
assert out.items[2].file_id == "file_id"
|
||||
assert out.items[2].quote == "text"
|
||||
assert out.items[2].start_index == 0
|
||||
assert out.items[2].end_index == 9
|
||||
assert out.items[2].citation_type == "file_citation"
|
||||
|
||||
assert out.items[3].file_id == "file_id_2"
|
||||
assert out.items[3].quote == "text again"
|
||||
assert out.items[3].start_index == 1
|
||||
assert out.items[3].end_index == 10
|
||||
assert out.items[3].citation_type == "file_path"
|
||||
|
||||
assert out.items[4].url == "http://example.com"
|
||||
assert out.items[4].quote == "text"
|
||||
assert out.items[4].start_index == 1
|
||||
assert out.items[4].end_index == 10
|
||||
assert out.items[4].title == "some title"
|
||||
assert out.items[4].citation_type == "url_citation"
|
||||
|
||||
assert out.metadata["step_id"] == "step_id"
|
||||
assert out.role == AuthorRole.USER
|
||||
|
||||
|
||||
def test_generate_annotation_content():
|
||||
message_text_file_path_ann = MessageTextFilePathAnnotation(
|
||||
text="some text",
|
||||
file_path=MessageTextFilePathDetails(file_id="file123"),
|
||||
start_index=0,
|
||||
end_index=9,
|
||||
)
|
||||
|
||||
message_text_file_citation_ann = MessageTextFileCitationAnnotation(
|
||||
text="some text",
|
||||
file_citation=MessageTextFileCitationDetails(file_id="file123"),
|
||||
start_index=0,
|
||||
end_index=9,
|
||||
)
|
||||
|
||||
for fake_ann in [message_text_file_path_ann, message_text_file_citation_ann]:
|
||||
out = generate_annotation_content(fake_ann)
|
||||
assert out.file_id == "file123"
|
||||
assert out.quote == "some text"
|
||||
assert out.start_index == 0
|
||||
assert out.end_index == 9
|
||||
|
||||
|
||||
def test_generate_streaming_message_content_text_annotations():
|
||||
message_delta_image_file_content = MessageDeltaImageFileContent(
|
||||
index=0,
|
||||
image_file=MessageDeltaImageFileContentObject(file_id="image_file"),
|
||||
)
|
||||
|
||||
MessageDeltaTextFileCitationAnnotation, MessageDeltaTextFilePathAnnotation
|
||||
|
||||
message_delta_text_content = MessageDeltaTextContent(
|
||||
index=0,
|
||||
text=MessageDeltaTextContentObject(
|
||||
value="some text",
|
||||
annotations=[
|
||||
MessageDeltaTextFileCitationAnnotation(
|
||||
index=0,
|
||||
file_citation=MessageDeltaTextFileCitationAnnotationObject(file_id="file123", quote="some text"),
|
||||
start_index=0,
|
||||
end_index=9,
|
||||
text="some text",
|
||||
),
|
||||
MessageDeltaTextFilePathAnnotation(
|
||||
index=0,
|
||||
file_path=MessageDeltaTextFilePathAnnotationObject(file_id="file123"),
|
||||
start_index=1,
|
||||
end_index=10,
|
||||
text="some text",
|
||||
),
|
||||
MessageDeltaTextUrlCitationAnnotation(
|
||||
index=0,
|
||||
url_citation=MessageDeltaTextUrlCitationDetails(
|
||||
title="some title",
|
||||
url="http://example.com",
|
||||
),
|
||||
start_index=2,
|
||||
end_index=11,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
delta = MessageDeltaChunk(
|
||||
id="chunk123",
|
||||
delta=MessageDelta(role="user", content=[message_delta_image_file_content, message_delta_text_content]),
|
||||
)
|
||||
|
||||
out = generate_streaming_message_content("assistant", delta)
|
||||
assert out is not None
|
||||
assert out.content == "some text"
|
||||
assert len(out.items) == 5
|
||||
assert out.items[0].file_id == "image_file"
|
||||
assert isinstance(out.items[0], StreamingFileReferenceContent)
|
||||
assert isinstance(out.items[1], StreamingTextContent)
|
||||
assert isinstance(out.items[2], StreamingAnnotationContent)
|
||||
|
||||
assert out.items[2].file_id == "file123"
|
||||
assert out.items[2].quote == "some text"
|
||||
assert out.items[2].start_index == 0
|
||||
assert out.items[2].end_index == 9
|
||||
assert out.items[2].citation_type == "file_citation"
|
||||
|
||||
assert isinstance(out.items[3], StreamingAnnotationContent)
|
||||
assert out.items[3].file_id == "file123"
|
||||
assert out.items[3].quote == "some text"
|
||||
assert out.items[3].start_index == 1
|
||||
assert out.items[3].end_index == 10
|
||||
assert out.items[3].citation_type == "file_path"
|
||||
|
||||
assert isinstance(out.items[4], StreamingAnnotationContent)
|
||||
assert out.items[4].url == "http://example.com"
|
||||
assert out.items[4].title == "some title"
|
||||
assert out.items[4].start_index == 2
|
||||
assert out.items[4].end_index == 11
|
||||
assert out.items[4].citation_type == "url_citation"
|
||||
|
||||
|
||||
def test_generate_annotation_content_url_annotation_without_indices():
|
||||
ann = MessageTextUrlCitationAnnotation(
|
||||
text="url text",
|
||||
url_citation=MessageTextUrlCitationDetails(title="", url="http://ex.com"),
|
||||
start_index=None,
|
||||
end_index=None,
|
||||
)
|
||||
out = generate_annotation_content(ann)
|
||||
assert out.file_id is None
|
||||
assert out.url == "http://ex.com"
|
||||
assert out.title == "" # preserve empty title
|
||||
assert out.quote == "url text"
|
||||
assert out.start_index is None
|
||||
assert out.end_index is None
|
||||
assert out.citation_type == "url_citation"
|
||||
|
||||
|
||||
def test_generate_streaming_annotation_content_url_quote_none_and_missing_indices():
|
||||
ann = MessageDeltaTextUrlCitationAnnotation(
|
||||
index=0,
|
||||
url_citation=MessageDeltaTextUrlCitationDetails(title="", url="http://ex.com"),
|
||||
start_index=None,
|
||||
end_index=None,
|
||||
)
|
||||
out = generate_streaming_annotation_content(ann)
|
||||
assert out.file_id is None
|
||||
assert out.url == "http://ex.com"
|
||||
assert out.title == ""
|
||||
assert out.quote is None # no .text on URL annotation
|
||||
assert out.start_index is None
|
||||
assert out.end_index is None
|
||||
assert out.citation_type == "url_citation"
|
||||
|
||||
|
||||
def test_generate_streaming_message_content_text_only_no_annotations():
|
||||
delta = MessageDeltaChunk(
|
||||
id="c1",
|
||||
delta=MessageDelta(
|
||||
role="assistant",
|
||||
content=[
|
||||
MessageDeltaTextContent(
|
||||
index=0,
|
||||
text=MessageDeltaTextContentObject(value="just text", annotations=[]),
|
||||
)
|
||||
],
|
||||
),
|
||||
)
|
||||
out = generate_streaming_message_content("assistant", delta, thread_msg_id="thread_1")
|
||||
assert out.content == "just text"
|
||||
assert len(out.items) == 1
|
||||
assert isinstance(out.items[0], StreamingTextContent)
|
||||
assert out.items[0].text == "just text"
|
||||
assert out.metadata.get(THREAD_MESSAGE_ID) == "thread_1"
|
||||
|
||||
|
||||
def test_generate_annotation_content_empty_title_and_url_only():
|
||||
ann = MessageTextUrlCitationAnnotation(
|
||||
text=None,
|
||||
url_citation=MessageTextUrlCitationDetails(title=None, url="http://empty.com"),
|
||||
start_index=5,
|
||||
end_index=10,
|
||||
)
|
||||
out = generate_annotation_content(ann)
|
||||
assert out.quote is None # allow None text
|
||||
assert out.url == "http://empty.com"
|
||||
assert out.title is None # allow None title
|
||||
assert out.start_index == 5
|
||||
assert out.end_index == 10
|
||||
|
||||
|
||||
def test_generate_streaming_annotation_content_file_and_citation_have_text():
|
||||
file_ann = MessageDeltaTextFileCitationAnnotation(
|
||||
index=0,
|
||||
file_citation=MessageDeltaTextFileCitationAnnotationObject(file_id="f1", quote="q1"),
|
||||
start_index=2,
|
||||
end_index=4,
|
||||
text="q1",
|
||||
)
|
||||
out = generate_streaming_annotation_content(file_ann)
|
||||
assert out.file_id == "f1"
|
||||
assert out.quote == "q1"
|
||||
assert out.citation_type == "file_citation"
|
||||
assert out.start_index == 2
|
||||
assert out.end_index == 4
|
||||
|
||||
|
||||
def test_generate_streaming_function_content_with_function():
|
||||
step_details = RunStepDeltaToolCallObject(
|
||||
tool_calls=[
|
||||
RunStepDeltaFunctionToolCall(
|
||||
index=0, id="tool123", function=RunStepDeltaFunction(name="some_func", arguments={"arg": "val"})
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
out = generate_streaming_function_content("my_agent", step_details)
|
||||
assert out is not None
|
||||
assert len(out.items) == 1
|
||||
assert isinstance(out.items[0], FunctionCallContent)
|
||||
assert out.items[0].function_name == "some_func"
|
||||
assert out.items[0].arguments == "{'arg': 'val'}"
|
||||
|
||||
|
||||
def test_get_function_call_contents_no_action():
|
||||
run = type("ThreadRunFake", (), {"required_action": None})()
|
||||
fc = get_function_call_contents(run, {})
|
||||
assert fc == []
|
||||
|
||||
|
||||
def test_get_function_call_contents_submit_tool_outputs():
|
||||
fake_function = MagicMock()
|
||||
fake_function.name = "test_function"
|
||||
fake_function.arguments = {"arg": "val"}
|
||||
|
||||
fake_tool_call = MagicMock(spec=RequiredFunctionToolCall)
|
||||
fake_tool_call.id = "tool_id"
|
||||
fake_tool_call.function = fake_function
|
||||
|
||||
run = MagicMock()
|
||||
run.required_action.submit_tool_outputs.tool_calls = [fake_tool_call]
|
||||
|
||||
function_steps = {}
|
||||
fc = get_function_call_contents(run, function_steps)
|
||||
|
||||
assert len(fc) == 1
|
||||
assert fc[0].id == "tool_id"
|
||||
assert fc[0].name == "test_function"
|
||||
assert fc[0].arguments == {"arg": "val"}
|
||||
|
||||
|
||||
def test_generate_function_call_content():
|
||||
fcc = FunctionCallContent(id="id123", name="func_name", arguments={"x": 1})
|
||||
msg = generate_function_call_content("my_agent", [fcc])
|
||||
assert len(msg.items) == 1
|
||||
assert msg.role == AuthorRole.ASSISTANT
|
||||
|
||||
|
||||
def test_generate_function_result_content():
|
||||
step = FunctionCallContent(id="123", name="func_name", arguments={"k": "v"})
|
||||
|
||||
tool_call = RunStepFunctionToolCall(
|
||||
id="123",
|
||||
function=RunStepFunctionToolCallDetails({
|
||||
"name": "func_name",
|
||||
"arguments": '{"k": "v"}',
|
||||
"output": "result_data",
|
||||
}),
|
||||
)
|
||||
msg = generate_function_result_content("my_agent", step, tool_call)
|
||||
assert len(msg.items) == 1
|
||||
assert msg.items[0].result == "result_data"
|
||||
assert msg.role == AuthorRole.TOOL
|
||||
|
||||
|
||||
def test_generate_code_interpreter_content():
|
||||
msg = generate_code_interpreter_content("my_agent", "some_code()")
|
||||
assert msg.content == "some_code()"
|
||||
assert msg.metadata["code"] is True
|
||||
|
||||
|
||||
def test_generate_streaming_code_interpreter_content_no_calls():
|
||||
step_details = type("Details", (), {"tool_calls": None})
|
||||
assert generate_streaming_code_interpreter_content("my_agent", step_details) is None
|
||||
|
||||
|
||||
def test_generate_bing_grounding_content():
|
||||
"""Test generate_bing_grounding_content with RunStepBingGroundingToolCall."""
|
||||
bing_grounding_tool_call = RunStepBingGroundingToolCall(
|
||||
id="call_gvgTmSL4hgdxWP4O7LLnwMlt",
|
||||
bing_grounding={
|
||||
"requesturl": "https://api.bing.microsoft.com/v7.0/search?q=search",
|
||||
"response_metadata": "{'market': 'en-US', 'num_docs_retrieved': 5, 'num_docs_actually_used': 5}",
|
||||
},
|
||||
)
|
||||
|
||||
msg = generate_bing_grounding_content("my_agent", bing_grounding_tool_call)
|
||||
|
||||
assert len(msg.items) == 1
|
||||
assert msg.role == AuthorRole.ASSISTANT
|
||||
assert isinstance(msg.items[0], FunctionCallContent)
|
||||
assert msg.items[0].id == "call_gvgTmSL4hgdxWP4O7LLnwMlt"
|
||||
assert msg.items[0].name == "bing_grounding"
|
||||
assert msg.items[0].function_name == "bing_grounding"
|
||||
assert msg.items[0].arguments["requesturl"] == "https://api.bing.microsoft.com/v7.0/search?q=search"
|
||||
assert msg.items[0].arguments["response_metadata"] == (
|
||||
"{'market': 'en-US', 'num_docs_retrieved': 5, 'num_docs_actually_used': 5}"
|
||||
)
|
||||
|
||||
|
||||
def test_generate_bing_custom_search_content():
|
||||
"""Test generate_bing_grounding_content with RunStepBingCustomSearchToolCall."""
|
||||
bing_custom_search_tool_call = RunStepBingCustomSearchToolCall(
|
||||
id="call_abc123def456ghi",
|
||||
bing_custom_search={
|
||||
"query": "semantic kernel python",
|
||||
"custom_config_id": "config_123",
|
||||
"search_results": "{'num_results': 10, 'top_result': 'Microsoft Semantic Kernel'}",
|
||||
},
|
||||
)
|
||||
|
||||
msg = generate_bing_grounding_content("my_agent", bing_custom_search_tool_call)
|
||||
|
||||
assert len(msg.items) == 1
|
||||
assert msg.role == AuthorRole.ASSISTANT
|
||||
assert isinstance(msg.items[0], FunctionCallContent)
|
||||
assert msg.items[0].id == "call_abc123def456ghi"
|
||||
assert msg.items[0].name == "bing_custom_search"
|
||||
assert msg.items[0].function_name == "bing_custom_search"
|
||||
assert msg.items[0].arguments["query"] == "semantic kernel python"
|
||||
assert msg.items[0].arguments["custom_config_id"] == "config_123"
|
||||
assert msg.items[0].arguments["search_results"] == (
|
||||
"{'num_results': 10, 'top_result': 'Microsoft Semantic Kernel'}"
|
||||
)
|
||||
@@ -0,0 +1,662 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from azure.ai.agents.models import (
|
||||
MessageTextContent,
|
||||
MessageTextDetails,
|
||||
RequiredFunctionToolCall,
|
||||
RequiredFunctionToolCallDetails,
|
||||
RunStep,
|
||||
RunStepCodeInterpreterToolCall,
|
||||
RunStepCodeInterpreterToolCallDetails,
|
||||
RunStepFunctionToolCall,
|
||||
RunStepFunctionToolCallDetails,
|
||||
RunStepMessageCreationDetails,
|
||||
RunStepMessageCreationReference,
|
||||
RunStepToolCallDetails,
|
||||
SubmitToolOutputsAction,
|
||||
SubmitToolOutputsDetails,
|
||||
ThreadMessage,
|
||||
ThreadRun,
|
||||
)
|
||||
from azure.ai.projects.aio import AIProjectClient
|
||||
from pytest import fixture
|
||||
|
||||
from semantic_kernel.agents.azure_ai.agent_thread_actions import AgentThreadActions
|
||||
from semantic_kernel.agents.azure_ai.azure_ai_agent import AzureAIAgent
|
||||
from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior
|
||||
from semantic_kernel.contents import FunctionCallContent, FunctionResultContent, TextContent
|
||||
from semantic_kernel.contents.chat_message_content import ChatMessageContent
|
||||
from semantic_kernel.contents.utils.author_role import AuthorRole
|
||||
from semantic_kernel.exceptions.agent_exceptions import AgentInvokeException
|
||||
from semantic_kernel.functions.kernel_arguments import KernelArguments
|
||||
from semantic_kernel.functions.kernel_function_decorator import kernel_function
|
||||
from semantic_kernel.functions.kernel_plugin import KernelPlugin
|
||||
from semantic_kernel.kernel import Kernel
|
||||
|
||||
|
||||
@fixture
|
||||
def mock_client():
|
||||
mock_thread = AsyncMock()
|
||||
mock_thread.id = "thread123"
|
||||
|
||||
mock_threads = MagicMock()
|
||||
mock_threads.create = AsyncMock(return_value=mock_thread)
|
||||
|
||||
mock_message = AsyncMock()
|
||||
mock_message.id = "message456"
|
||||
|
||||
mock_messages = MagicMock()
|
||||
mock_messages.create = AsyncMock(return_value="someMessage")
|
||||
|
||||
mock_agents = MagicMock()
|
||||
mock_agents.threads = mock_threads
|
||||
mock_agents.messages = mock_messages
|
||||
|
||||
mock_client = AsyncMock(spec=AIProjectClient)
|
||||
mock_client.agents = mock_agents
|
||||
|
||||
return mock_client
|
||||
|
||||
|
||||
async def test_agent_thread_actions_create_thread(mock_client):
|
||||
thread_id = await AgentThreadActions.create_thread(mock_client)
|
||||
assert thread_id == "thread123"
|
||||
|
||||
|
||||
async def test_agent_thread_actions_create_message(mock_client):
|
||||
msg = ChatMessageContent(role=AuthorRole.USER, content="some content")
|
||||
out = await AgentThreadActions.create_message(mock_client, "threadXYZ", msg)
|
||||
assert out == "someMessage"
|
||||
|
||||
|
||||
async def test_agent_thread_actions_create_message_no_content():
|
||||
class FakeAgentClient:
|
||||
create_message = AsyncMock(return_value="should_not_be_called")
|
||||
|
||||
class FakeClient:
|
||||
agents = FakeAgentClient()
|
||||
|
||||
message = ChatMessageContent(role=AuthorRole.USER, content=" ")
|
||||
out = await AgentThreadActions.create_message(FakeClient(), "threadXYZ", message)
|
||||
assert out is None
|
||||
assert FakeAgentClient.create_message.await_count == 0
|
||||
|
||||
|
||||
async def test_agent_thread_actions_invoke(ai_project_client: AIProjectClient, ai_agent_definition):
|
||||
agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition)
|
||||
|
||||
# Properly construct nested mocks without re-spec'ing from a mock
|
||||
mock_thread_run = ThreadRun(
|
||||
id="run123",
|
||||
thread_id="thread123",
|
||||
status="running",
|
||||
instructions="test agent",
|
||||
created_at=int(datetime.now(timezone.utc).timestamp()),
|
||||
model="model",
|
||||
)
|
||||
|
||||
agent.client.agents.runs = MagicMock()
|
||||
agent.client.agents.runs.create = AsyncMock(return_value=mock_thread_run)
|
||||
agent.client.agents.runs.get = AsyncMock(return_value=mock_thread_run)
|
||||
|
||||
async def mock_poll_run_status(*args, **kwargs):
|
||||
yield RunStep(
|
||||
type="message_creation",
|
||||
id="msg123",
|
||||
thread_id="thread123",
|
||||
run_id="run123",
|
||||
created_at=int(datetime.now(timezone.utc).timestamp()),
|
||||
completed_at=int(datetime.now(timezone.utc).timestamp()),
|
||||
status="completed",
|
||||
agent_id="agent123",
|
||||
step_details=RunStepMessageCreationDetails(
|
||||
message_creation=RunStepMessageCreationReference(
|
||||
message_id="msg123",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
agent.client.agents.run_steps = MagicMock()
|
||||
agent.client.agents.run_steps.list = mock_poll_run_status
|
||||
|
||||
mock_message = ThreadMessage(
|
||||
id="msg123",
|
||||
thread_id="thread123",
|
||||
run_id="run123",
|
||||
created_at=int(datetime.now(timezone.utc).timestamp()),
|
||||
completed_at=int(datetime.now(timezone.utc).timestamp()),
|
||||
status="completed",
|
||||
agent_id="agent123",
|
||||
role="assistant",
|
||||
content=[MessageTextContent(text=MessageTextDetails(value="some message", annotations=[]))],
|
||||
)
|
||||
|
||||
agent.client.agents.messages = MagicMock()
|
||||
agent.client.agents.messages.get = AsyncMock(return_value=mock_message)
|
||||
|
||||
async for is_visible, message in AgentThreadActions.invoke(
|
||||
agent=agent, thread_id="thread123", kernel=AsyncMock(spec=Kernel)
|
||||
):
|
||||
assert str(message.content) == "some message"
|
||||
break
|
||||
|
||||
|
||||
async def test_agent_thread_actions_invoke_with_requires_action(ai_project_client, ai_agent_definition):
|
||||
agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition)
|
||||
agent.client.agents = MagicMock()
|
||||
|
||||
mock_thread_run = ThreadRun(
|
||||
id="run123",
|
||||
thread_id="thread123",
|
||||
status="running",
|
||||
instructions="test agent",
|
||||
created_at=int(datetime.now(timezone.utc).timestamp()),
|
||||
model="model",
|
||||
)
|
||||
|
||||
agent.client.agents = MagicMock()
|
||||
|
||||
agent.client.agents.runs = MagicMock()
|
||||
agent.client.agents.runs.create = AsyncMock(return_value=mock_thread_run)
|
||||
agent.client.agents.runs.get = AsyncMock(return_value=mock_thread_run)
|
||||
agent.client.agents.runs.submit_tool_outputs = AsyncMock()
|
||||
|
||||
poll_count = 0
|
||||
|
||||
async def mock_poll_run_status(*args, **kwargs):
|
||||
nonlocal poll_count
|
||||
if poll_count == 0:
|
||||
mock_thread_run.status = "requires_action"
|
||||
mock_thread_run.required_action = SubmitToolOutputsAction(
|
||||
submit_tool_outputs=SubmitToolOutputsDetails(
|
||||
tool_calls=[
|
||||
RequiredFunctionToolCall(
|
||||
id="tool_call_id",
|
||||
function=RequiredFunctionToolCallDetails(
|
||||
name="mock_function_call", arguments={"arg": "value"}
|
||||
),
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
else:
|
||||
mock_thread_run.status = "completed"
|
||||
poll_count += 1
|
||||
return mock_thread_run
|
||||
|
||||
def mock_get_function_call_contents(run: ThreadRun, function_steps: dict):
|
||||
function_call_content = FunctionCallContent(
|
||||
name="mock_function_call",
|
||||
arguments={"arg": "value"},
|
||||
id="tool_call_id",
|
||||
)
|
||||
function_steps[function_call_content.id] = function_call_content
|
||||
return [function_call_content]
|
||||
|
||||
mock_run_step_tool_calls = RunStep(
|
||||
type="tool_calls",
|
||||
id="tool_step123",
|
||||
thread_id="thread123",
|
||||
run_id="run123",
|
||||
created_at=int(datetime.now(timezone.utc).timestamp()),
|
||||
completed_at=int(datetime.now(timezone.utc).timestamp()),
|
||||
status="completed",
|
||||
agent_id="agent123",
|
||||
step_details=RunStepToolCallDetails(
|
||||
tool_calls=[
|
||||
# 1. This will yield FunctionResultContent
|
||||
RunStepFunctionToolCall(
|
||||
id="tool_call_id",
|
||||
function=RunStepFunctionToolCallDetails({
|
||||
"name": "mock_function_call",
|
||||
"arguments": '{"arg": "value"}',
|
||||
"output": "some output",
|
||||
}),
|
||||
),
|
||||
# 2. This will yield TextContent
|
||||
RunStepCodeInterpreterToolCall(
|
||||
id="tool_call_id",
|
||||
code_interpreter=RunStepCodeInterpreterToolCallDetails(
|
||||
input="some code",
|
||||
),
|
||||
),
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
mock_run_step_message_creation = RunStep(
|
||||
type="message_creation",
|
||||
id="msg_step123",
|
||||
thread_id="thread123",
|
||||
run_id="run123",
|
||||
created_at=int(datetime.now(timezone.utc).timestamp()),
|
||||
completed_at=int(datetime.now(timezone.utc).timestamp()),
|
||||
status="completed",
|
||||
agent_id="agent123",
|
||||
step_details=RunStepMessageCreationDetails(
|
||||
message_creation=RunStepMessageCreationReference(message_id="msg123")
|
||||
),
|
||||
)
|
||||
|
||||
mock_run_steps = [mock_run_step_tool_calls, mock_run_step_message_creation]
|
||||
|
||||
async def mock_list_run_steps(*args, **kwargs):
|
||||
for step in mock_run_steps:
|
||||
yield step
|
||||
|
||||
agent.client.agents.run_steps = MagicMock()
|
||||
agent.client.agents.run_steps.list = mock_list_run_steps
|
||||
|
||||
mock_message = ThreadMessage(
|
||||
id="msg123",
|
||||
thread_id="thread123",
|
||||
run_id="run123",
|
||||
created_at=int(datetime.now(timezone.utc).timestamp()),
|
||||
completed_at=int(datetime.now(timezone.utc).timestamp()),
|
||||
status="completed",
|
||||
agent_id="agent123",
|
||||
role="assistant",
|
||||
content=[MessageTextContent(text=MessageTextDetails(value="some message", annotations=[]))],
|
||||
)
|
||||
agent.client.agents.runs.get = AsyncMock(return_value=mock_message)
|
||||
|
||||
agent.client.agents.runs.submit_tool_outputs = AsyncMock()
|
||||
|
||||
with (
|
||||
patch.object(AgentThreadActions, "_poll_run_status", side_effect=mock_poll_run_status),
|
||||
patch(
|
||||
"semantic_kernel.agents.azure_ai.agent_thread_actions.get_function_call_contents",
|
||||
side_effect=mock_get_function_call_contents,
|
||||
),
|
||||
patch.object(AgentThreadActions, "_invoke_function_calls", return_value=[None]),
|
||||
):
|
||||
messages = []
|
||||
async for is_visible, content in AgentThreadActions.invoke(
|
||||
agent=agent,
|
||||
thread_id="thread123",
|
||||
kernel=AsyncMock(spec=Kernel),
|
||||
):
|
||||
messages.append((is_visible, content))
|
||||
|
||||
assert len(messages) == 3, "There should be three yields in total."
|
||||
|
||||
assert isinstance(messages[0][1].items[0], FunctionCallContent)
|
||||
assert isinstance(messages[1][1].items[0], FunctionResultContent)
|
||||
assert isinstance(messages[2][1].items[0], TextContent)
|
||||
|
||||
agent.client.agents.runs.submit_tool_outputs.assert_awaited_once()
|
||||
|
||||
|
||||
class MockEvent:
|
||||
def __init__(self, event, data):
|
||||
self.event = event
|
||||
self.data = data
|
||||
|
||||
def __iter__(self):
|
||||
return iter((self.event, self.data, None))
|
||||
|
||||
|
||||
class MockRunData:
|
||||
def __init__(self, id, status, content: str | None = None):
|
||||
self.id = id
|
||||
self.status = status
|
||||
self.content = content
|
||||
|
||||
|
||||
class MockAsyncIterable:
|
||||
def __init__(self, items):
|
||||
self.items = items.copy()
|
||||
|
||||
def __aiter__(self):
|
||||
self._iter = iter(self.items)
|
||||
return self
|
||||
|
||||
async def __anext__(self):
|
||||
try:
|
||||
return next(self._iter)
|
||||
except StopIteration:
|
||||
raise StopAsyncIteration
|
||||
|
||||
|
||||
class MockStream:
|
||||
def __init__(self, events):
|
||||
self.events = events
|
||||
|
||||
async def __aenter__(self):
|
||||
return MockAsyncIterable(self.events)
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
pass
|
||||
|
||||
|
||||
async def test_agent_thread_actions_invoke_stream(ai_project_client, ai_agent_definition):
|
||||
agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition)
|
||||
agent.client.agents = AsyncMock()
|
||||
|
||||
events = [
|
||||
MockEvent("thread.run.created", MockRunData(id="run_1", status="queued")),
|
||||
MockEvent("thread.message.created", MockRunData(id="msg_1", status="created", content="Hello")),
|
||||
MockEvent("thread.run.in_progress", MockRunData(id="run_1", status="in_progress")),
|
||||
MockEvent("thread.run.completed", MockRunData(id="run_1", status="completed")),
|
||||
]
|
||||
|
||||
main_run_stream = MockStream(events)
|
||||
agent.client.agents.create_stream.return_value = main_run_stream
|
||||
|
||||
with (
|
||||
patch.object(AgentThreadActions, "_invoke_function_calls", return_value=None),
|
||||
patch.object(AgentThreadActions, "_format_tool_outputs", return_value=[{"type": "mock_tool_output"}]),
|
||||
):
|
||||
collected_messages = []
|
||||
async for content in AgentThreadActions.invoke_stream(
|
||||
agent=agent,
|
||||
thread_id="thread123",
|
||||
kernel=AsyncMock(spec=Kernel),
|
||||
):
|
||||
collected_messages.append(content)
|
||||
assert isinstance(content, ChatMessageContent)
|
||||
assert content.metadata.get("message_id") == "msg_1"
|
||||
|
||||
|
||||
# region Security tests for tools override and function_choice_behavior
|
||||
|
||||
|
||||
async def test_validate_function_choice_behavior_rejects_required():
|
||||
"""Required FCB is not supported for agent invocations."""
|
||||
with pytest.raises(AgentInvokeException, match="not supported"):
|
||||
AgentThreadActions._validate_function_choice_behavior(FunctionChoiceBehavior.Required())
|
||||
|
||||
|
||||
async def test_validate_function_choice_behavior_accepts_auto():
|
||||
"""Auto FCB should be accepted without error."""
|
||||
AgentThreadActions._validate_function_choice_behavior(FunctionChoiceBehavior.Auto())
|
||||
|
||||
|
||||
async def test_validate_function_choice_behavior_rejects_none_invoke():
|
||||
"""NoneInvoke FCB is not supported for agent invocations."""
|
||||
with pytest.raises(AgentInvokeException, match="not supported"):
|
||||
AgentThreadActions._validate_function_choice_behavior(FunctionChoiceBehavior.NoneInvoke())
|
||||
|
||||
|
||||
async def test_validate_function_choice_behavior_accepts_none():
|
||||
"""None (no FCB) should be accepted."""
|
||||
AgentThreadActions._validate_function_choice_behavior(None)
|
||||
|
||||
|
||||
async def test_validate_function_choice_behavior_rejects_auto_invoke_false():
|
||||
"""Auto with auto_invoke=False is not supported for agent invocations."""
|
||||
with pytest.raises(AgentInvokeException, match="auto_invoke"):
|
||||
AgentThreadActions._validate_function_choice_behavior(FunctionChoiceBehavior.Auto(auto_invoke=False))
|
||||
|
||||
|
||||
async def test_validate_function_choice_behavior_rejects_empty_filters():
|
||||
"""Empty filters dict should be rejected."""
|
||||
fcb = FunctionChoiceBehavior.Auto()
|
||||
fcb.filters = {}
|
||||
with pytest.raises(AgentInvokeException, match="must not be empty"):
|
||||
AgentThreadActions._validate_function_choice_behavior(fcb)
|
||||
|
||||
|
||||
async def test_validate_function_choice_behavior_rejects_unknown_filter_keys():
|
||||
"""Unknown filter keys should be rejected."""
|
||||
fcb = FunctionChoiceBehavior.Auto()
|
||||
# Bypass Pydantic validation to simulate a mistyped key reaching the validator
|
||||
object.__setattr__(fcb, "filters", {"include_functions": ["foo"]})
|
||||
with pytest.raises(AgentInvokeException, match="Unknown filter key"):
|
||||
AgentThreadActions._validate_function_choice_behavior(fcb)
|
||||
|
||||
|
||||
async def test_validate_function_choice_behavior_accepts_valid_filters():
|
||||
"""Valid filter keys should be accepted."""
|
||||
AgentThreadActions._validate_function_choice_behavior(
|
||||
FunctionChoiceBehavior.Auto(filters={"included_functions": ["plugin-func"]})
|
||||
)
|
||||
|
||||
|
||||
async def test_get_tools_with_tools_override(ai_project_client, ai_agent_definition):
|
||||
"""When tools_override is provided, it should replace agent.definition.tools."""
|
||||
from azure.ai.agents.models import CodeInterpreterToolDefinition
|
||||
|
||||
agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition)
|
||||
kernel = MagicMock(spec=Kernel)
|
||||
kernel.get_full_list_of_function_metadata.return_value = []
|
||||
|
||||
override_tool = CodeInterpreterToolDefinition()
|
||||
tools = AgentThreadActions._get_tools(agent=agent, kernel=kernel, tools_override=[override_tool])
|
||||
# Should contain the override tool, not agent.definition.tools
|
||||
assert any(
|
||||
(isinstance(t, CodeInterpreterToolDefinition) or (isinstance(t, dict) and t.get("type") == "code_interpreter"))
|
||||
for t in tools
|
||||
)
|
||||
|
||||
|
||||
async def test_get_tools_with_fcb_filters(ai_project_client, ai_agent_definition):
|
||||
"""When function_choice_behavior has filters, only matching functions should be included."""
|
||||
agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition)
|
||||
kernel = MagicMock(spec=Kernel)
|
||||
|
||||
# Simulate filtered metadata
|
||||
mock_metadata = MagicMock()
|
||||
mock_metadata.fully_qualified_name = "Plugin-AllowedFunc"
|
||||
mock_metadata.name = "AllowedFunc"
|
||||
mock_metadata.plugin_name = "Plugin"
|
||||
mock_metadata.description = "An allowed function"
|
||||
mock_metadata.parameters = []
|
||||
mock_metadata.is_prompt = False
|
||||
mock_metadata.return_parameter = MagicMock()
|
||||
mock_metadata.return_parameter.description = ""
|
||||
mock_metadata.return_parameter.type_ = "str"
|
||||
mock_metadata.additional_properties = {}
|
||||
|
||||
kernel.get_list_of_function_metadata.return_value = [mock_metadata]
|
||||
kernel.get_full_list_of_function_metadata.return_value = []
|
||||
|
||||
fcb = FunctionChoiceBehavior.Auto(filters={"included_functions": ["Plugin-AllowedFunc"]})
|
||||
AgentThreadActions._get_tools(agent=agent, kernel=kernel, function_choice_behavior=fcb)
|
||||
# Should have called get_list_of_function_metadata with the filters
|
||||
kernel.get_list_of_function_metadata.assert_called_once_with(fcb.filters)
|
||||
|
||||
|
||||
async def test_get_tools_with_fcb_disable_kernel_functions(ai_project_client, ai_agent_definition):
|
||||
"""When enable_kernel_functions=False, no kernel functions should be included."""
|
||||
agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition)
|
||||
kernel = MagicMock(spec=Kernel)
|
||||
|
||||
fcb = FunctionChoiceBehavior.Auto(enable_kernel_functions=False)
|
||||
AgentThreadActions._get_tools(agent=agent, kernel=kernel, function_choice_behavior=fcb)
|
||||
# Full list is called for validation, but filtered list should not be called
|
||||
kernel.get_full_list_of_function_metadata.assert_called_once()
|
||||
kernel.get_list_of_function_metadata.assert_not_called()
|
||||
|
||||
|
||||
async def test_invoke_function_calls_passes_function_behavior():
|
||||
"""_invoke_function_calls should pass function_behavior to kernel.invoke_function_call."""
|
||||
mock_kernel = AsyncMock(spec=Kernel)
|
||||
mock_kernel.invoke_function_call.return_value = None
|
||||
|
||||
fcc = FunctionCallContent(name="Plugin-Func", arguments={}, id="call1")
|
||||
from semantic_kernel.contents.chat_history import ChatHistory
|
||||
|
||||
chat_history = ChatHistory()
|
||||
fcb = FunctionChoiceBehavior.Auto(filters={"included_functions": ["Plugin-Func"]})
|
||||
|
||||
await AgentThreadActions._invoke_function_calls(
|
||||
kernel=mock_kernel,
|
||||
fccs=[fcc],
|
||||
chat_history=chat_history,
|
||||
arguments=KernelArguments(),
|
||||
function_choice_behavior=fcb,
|
||||
)
|
||||
|
||||
mock_kernel.invoke_function_call.assert_awaited_once()
|
||||
call_kwargs = mock_kernel.invoke_function_call.call_args
|
||||
assert call_kwargs.kwargs.get("function_behavior") is fcb
|
||||
|
||||
|
||||
async def test_invoke_function_calls_passes_disabled_kernel_functions():
|
||||
"""_invoke_function_calls should pass enable_kernel_functions=False FCB to kernel."""
|
||||
mock_kernel = AsyncMock(spec=Kernel)
|
||||
mock_kernel.invoke_function_call.return_value = None
|
||||
|
||||
fcc = FunctionCallContent(name="Plugin-Func", arguments={}, id="call1")
|
||||
from semantic_kernel.contents.chat_history import ChatHistory
|
||||
|
||||
chat_history = ChatHistory()
|
||||
fcb = FunctionChoiceBehavior.Auto(enable_kernel_functions=False)
|
||||
|
||||
await AgentThreadActions._invoke_function_calls(
|
||||
kernel=mock_kernel,
|
||||
fccs=[fcc],
|
||||
chat_history=chat_history,
|
||||
arguments=KernelArguments(),
|
||||
function_choice_behavior=fcb,
|
||||
)
|
||||
|
||||
mock_kernel.invoke_function_call.assert_awaited_once()
|
||||
call_kwargs = mock_kernel.invoke_function_call.call_args
|
||||
passed_behavior = call_kwargs.kwargs.get("function_behavior")
|
||||
assert passed_behavior is fcb
|
||||
assert not passed_behavior.enable_kernel_functions
|
||||
|
||||
|
||||
async def test_invoke_function_calls_blocks_disallowed_function():
|
||||
"""A real Kernel should block a function call not in the FCB allowlist.
|
||||
|
||||
This verifies that the enforcement in kernel.invoke_function_call actually
|
||||
rejects a disallowed function name when filters are provided, rather than
|
||||
only asserting that the kwarg is forwarded.
|
||||
"""
|
||||
from semantic_kernel.contents.chat_history import ChatHistory
|
||||
from semantic_kernel.functions.kernel_function_from_method import KernelFunctionFromMethod
|
||||
|
||||
@kernel_function
|
||||
def allowed_func() -> str:
|
||||
return "allowed"
|
||||
|
||||
@kernel_function
|
||||
def disallowed_func() -> str:
|
||||
return "disallowed"
|
||||
|
||||
kernel = Kernel()
|
||||
kernel.add_plugin(
|
||||
KernelPlugin(
|
||||
name="Plugin",
|
||||
functions=[
|
||||
KernelFunctionFromMethod(method=allowed_func, plugin_name="Plugin"),
|
||||
KernelFunctionFromMethod(method=disallowed_func, plugin_name="Plugin"),
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
fcb = FunctionChoiceBehavior.Auto(filters={"included_functions": ["Plugin-allowed_func"]})
|
||||
|
||||
# Call a function NOT in the allowlist
|
||||
fcc = FunctionCallContent(
|
||||
name="Plugin-disallowed_func",
|
||||
plugin_name="Plugin",
|
||||
function_name="disallowed_func",
|
||||
arguments={},
|
||||
id="call1",
|
||||
)
|
||||
chat_history = ChatHistory()
|
||||
|
||||
result = await kernel.invoke_function_call(
|
||||
function_call=fcc,
|
||||
chat_history=chat_history,
|
||||
function_behavior=fcb,
|
||||
)
|
||||
# invoke_function_call catches the FunctionExecutionException and returns None,
|
||||
# adding an error message to chat_history instead of raising.
|
||||
assert result is None
|
||||
assert len(chat_history.messages) == 1
|
||||
result_item = chat_history.messages[0].items[0]
|
||||
assert "not part of the provided tools" in str(result_item.result)
|
||||
|
||||
|
||||
async def test_invoke_function_calls_allows_permitted_function():
|
||||
"""A real Kernel should allow a function call that IS in the FCB allowlist."""
|
||||
from semantic_kernel.contents.chat_history import ChatHistory
|
||||
from semantic_kernel.functions.kernel_function_from_method import KernelFunctionFromMethod
|
||||
|
||||
@kernel_function
|
||||
def allowed_func() -> str:
|
||||
return "ok"
|
||||
|
||||
@kernel_function
|
||||
def other_func() -> str:
|
||||
return "other"
|
||||
|
||||
kernel = Kernel()
|
||||
kernel.add_plugin(
|
||||
KernelPlugin(
|
||||
name="Plugin",
|
||||
functions=[
|
||||
KernelFunctionFromMethod(method=allowed_func, plugin_name="Plugin"),
|
||||
KernelFunctionFromMethod(method=other_func, plugin_name="Plugin"),
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
fcb = FunctionChoiceBehavior.Auto(filters={"included_functions": ["Plugin-allowed_func"]})
|
||||
|
||||
fcc = FunctionCallContent(
|
||||
name="Plugin-allowed_func",
|
||||
plugin_name="Plugin",
|
||||
function_name="allowed_func",
|
||||
arguments={},
|
||||
id="call1",
|
||||
)
|
||||
chat_history = ChatHistory()
|
||||
|
||||
await kernel.invoke_function_call(
|
||||
function_call=fcc,
|
||||
chat_history=chat_history,
|
||||
function_behavior=fcb,
|
||||
)
|
||||
# Should succeed — the function result should be in chat_history
|
||||
assert len(chat_history.messages) == 1
|
||||
result_item = chat_history.messages[0].items[0]
|
||||
assert "ok" in str(result_item.result)
|
||||
|
||||
|
||||
async def test_invoke_raises_for_non_auto_fcb(ai_project_client, ai_agent_definition):
|
||||
"""Calling AgentThreadActions.invoke() with a non-Auto FCB should raise before any API call."""
|
||||
agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition)
|
||||
agent.client.agents = AsyncMock()
|
||||
|
||||
with pytest.raises(AgentInvokeException, match="not supported"):
|
||||
async for _ in AgentThreadActions.invoke(
|
||||
agent=agent,
|
||||
thread_id="thread123",
|
||||
kernel=Kernel(),
|
||||
function_choice_behavior=FunctionChoiceBehavior.Required(),
|
||||
):
|
||||
pass
|
||||
|
||||
# No API calls should have been made
|
||||
agent.client.agents.runs.create.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_invoke_stream_raises_for_non_auto_fcb(ai_project_client, ai_agent_definition):
|
||||
"""Calling AgentThreadActions.invoke_stream() with a non-Auto FCB should raise before any API call."""
|
||||
agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition)
|
||||
agent.client.agents = AsyncMock()
|
||||
|
||||
with pytest.raises(AgentInvokeException, match="not supported"):
|
||||
async for _ in AgentThreadActions.invoke_stream(
|
||||
agent=agent,
|
||||
thread_id="thread123",
|
||||
kernel=Kernel(),
|
||||
function_choice_behavior=FunctionChoiceBehavior.NoneInvoke(),
|
||||
):
|
||||
pass
|
||||
|
||||
# No API calls should have been made
|
||||
agent.client.agents.create_stream.assert_not_called()
|
||||
|
||||
|
||||
# endregion
|
||||
@@ -0,0 +1,478 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch
|
||||
|
||||
import pytest
|
||||
from azure.ai.projects.aio import AIProjectClient
|
||||
from azure.core.credentials_async import AsyncTokenCredential
|
||||
|
||||
from semantic_kernel.agents.agent import AgentResponseItem
|
||||
from semantic_kernel.agents.azure_ai.azure_ai_agent import AzureAIAgent, AzureAIAgentThread
|
||||
from semantic_kernel.agents.channels.agent_channel import AgentChannel
|
||||
from semantic_kernel.connectors.ai.function_choice_behavior import FunctionChoiceBehavior
|
||||
from semantic_kernel.contents.chat_history import ChatHistory
|
||||
from semantic_kernel.contents.chat_message_content import ChatMessageContent
|
||||
from semantic_kernel.contents.function_call_content import FunctionCallContent
|
||||
from semantic_kernel.contents.function_result_content import FunctionResultContent
|
||||
from semantic_kernel.contents.streaming_chat_message_content import StreamingChatMessageContent
|
||||
from semantic_kernel.contents.utils.author_role import AuthorRole
|
||||
from semantic_kernel.exceptions.agent_exceptions import AgentInitializationException, AgentInvokeException
|
||||
|
||||
|
||||
async def test_azure_ai_agent_init(ai_project_client, ai_agent_definition):
|
||||
agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition)
|
||||
assert agent.id == "agent123"
|
||||
assert agent.name == "agentName"
|
||||
assert agent.description == "desc"
|
||||
|
||||
|
||||
async def test_azure_ai_agent_init_with_plugins_via_constructor(
|
||||
ai_project_client, ai_agent_definition, custom_plugin_class
|
||||
):
|
||||
agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition, plugins=[custom_plugin_class()])
|
||||
assert agent.id == "agent123"
|
||||
assert agent.name == "agentName"
|
||||
assert agent.description == "desc"
|
||||
assert agent.kernel.plugins is not None
|
||||
assert len(agent.kernel.plugins) == 1
|
||||
|
||||
|
||||
async def test_azure_ai_agent_get_response(ai_project_client, ai_agent_definition):
|
||||
agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition)
|
||||
|
||||
thread = AsyncMock(spec=AzureAIAgentThread)
|
||||
|
||||
async def fake_invoke(*args, **kwargs):
|
||||
yield True, ChatMessageContent(role=AuthorRole.ASSISTANT, content="content")
|
||||
|
||||
with patch(
|
||||
"semantic_kernel.agents.azure_ai.agent_thread_actions.AgentThreadActions.invoke",
|
||||
side_effect=fake_invoke,
|
||||
):
|
||||
response = await agent.get_response(messages="message", thread=thread)
|
||||
assert response.message.role == AuthorRole.ASSISTANT
|
||||
assert response.message.content == "content"
|
||||
assert response.thread is not None
|
||||
|
||||
|
||||
async def test_azure_ai_agent_get_response_exception(ai_project_client, ai_agent_definition):
|
||||
agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition)
|
||||
thread = AsyncMock(spec=AzureAIAgentThread)
|
||||
|
||||
async def fake_invoke(*args, **kwargs):
|
||||
yield False, ChatMessageContent(role=AuthorRole.ASSISTANT, content="content")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"semantic_kernel.agents.azure_ai.agent_thread_actions.AgentThreadActions.invoke",
|
||||
side_effect=fake_invoke,
|
||||
),
|
||||
pytest.raises(AgentInvokeException),
|
||||
):
|
||||
await agent.get_response(messages="message", thread=thread)
|
||||
|
||||
|
||||
async def test_azure_ai_agent_invoke(ai_project_client, ai_agent_definition):
|
||||
agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition)
|
||||
thread = AsyncMock(spec=AzureAIAgentThread)
|
||||
results = []
|
||||
|
||||
async def fake_invoke(*args, **kwargs):
|
||||
yield True, ChatMessageContent(role=AuthorRole.ASSISTANT, content="content")
|
||||
|
||||
with patch(
|
||||
"semantic_kernel.agents.azure_ai.agent_thread_actions.AgentThreadActions.invoke",
|
||||
side_effect=fake_invoke,
|
||||
):
|
||||
async for item in agent.invoke(messages="message", thread=thread):
|
||||
results.append(item)
|
||||
|
||||
assert len(results) == 1
|
||||
|
||||
|
||||
async def test_azure_ai_agent_invoke_yields_visible_assistant_message(ai_project_client, ai_agent_definition):
|
||||
agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition)
|
||||
thread = AsyncMock(spec=AzureAIAgentThread)
|
||||
results = []
|
||||
|
||||
assistant_msg = ChatMessageContent(role=AuthorRole.ASSISTANT, content="assistant says hi")
|
||||
|
||||
async def fake_invoke(*args, **kwargs):
|
||||
yield True, assistant_msg
|
||||
|
||||
with patch(
|
||||
"semantic_kernel.agents.azure_ai.agent_thread_actions.AgentThreadActions.invoke",
|
||||
side_effect=fake_invoke,
|
||||
):
|
||||
async for item in agent.invoke(messages="message", thread=thread):
|
||||
results.append(item)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].message is assistant_msg
|
||||
|
||||
|
||||
async def test_azure_ai_agent_invoke_emits_tool_message_via_callback_only(ai_project_client, ai_agent_definition):
|
||||
agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition)
|
||||
thread = AsyncMock(spec=AzureAIAgentThread)
|
||||
|
||||
callback_results = []
|
||||
|
||||
async def handle_callback(msg: ChatMessageContent) -> None:
|
||||
callback_results.append(msg)
|
||||
|
||||
tool_msg = ChatMessageContent(role=AuthorRole.ASSISTANT, content="tool call")
|
||||
tool_msg.items = [FunctionCallContent(name="tool", arguments="{}")]
|
||||
|
||||
async def fake_invoke(*args, **kwargs):
|
||||
yield False, tool_msg
|
||||
|
||||
with patch(
|
||||
"semantic_kernel.agents.azure_ai.agent_thread_actions.AgentThreadActions.invoke",
|
||||
side_effect=fake_invoke,
|
||||
):
|
||||
async for _ in agent.invoke(messages="message", thread=thread, on_intermediate_message=handle_callback):
|
||||
pass
|
||||
|
||||
assert callback_results == [tool_msg]
|
||||
|
||||
|
||||
async def test_azure_ai_agent_invoke_suppresses_tool_message_without_callback(ai_project_client, ai_agent_definition):
|
||||
agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition)
|
||||
thread = AsyncMock(spec=AzureAIAgentThread)
|
||||
|
||||
tool_msg = ChatMessageContent(role=AuthorRole.ASSISTANT, content="tool call")
|
||||
tool_msg.items = [FunctionCallContent(name="tool", arguments="{}")]
|
||||
|
||||
async def fake_invoke(*args, **kwargs):
|
||||
yield False, tool_msg # Not visible, no callback
|
||||
|
||||
with patch(
|
||||
"semantic_kernel.agents.azure_ai.agent_thread_actions.AgentThreadActions.invoke",
|
||||
side_effect=fake_invoke,
|
||||
):
|
||||
results = [item async for item in agent.invoke(messages="message", thread=thread)]
|
||||
|
||||
assert results == [] # Tool message should be suppressed
|
||||
|
||||
|
||||
async def test_azure_ai_agent_invoke_stream(ai_project_client, ai_agent_definition):
|
||||
agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition)
|
||||
thread = AsyncMock(spec=AzureAIAgentThread)
|
||||
results = []
|
||||
|
||||
async def fake_invoke(*args, **kwargs):
|
||||
yield ChatMessageContent(role=AuthorRole.ASSISTANT, content="content")
|
||||
|
||||
with patch(
|
||||
"semantic_kernel.agents.azure_ai.agent_thread_actions.AgentThreadActions.invoke_stream",
|
||||
side_effect=fake_invoke,
|
||||
):
|
||||
async for item in agent.invoke_stream(messages="message", thread=thread):
|
||||
results.append(item)
|
||||
|
||||
assert len(results) == 1
|
||||
|
||||
|
||||
async def test_azure_ai_agent_invoke_stream_with_on_new_message_callback(ai_project_client, ai_agent_definition):
|
||||
agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition)
|
||||
thread = AsyncMock(spec=AzureAIAgentThread)
|
||||
thread.id = "test_thread_id"
|
||||
results = []
|
||||
|
||||
final_chat_history = ChatHistory()
|
||||
|
||||
async def handle_stream_completion(message: ChatMessageContent) -> None:
|
||||
final_chat_history.add_message(message)
|
||||
|
||||
# Fake collected messages
|
||||
fake_message = StreamingChatMessageContent(role=AuthorRole.ASSISTANT, content="fake content", choice_index=0)
|
||||
|
||||
async def fake_invoke(*args, output_messages=None, **kwargs):
|
||||
if output_messages is not None:
|
||||
output_messages.append(fake_message)
|
||||
yield fake_message
|
||||
|
||||
with patch(
|
||||
"semantic_kernel.agents.azure_ai.agent_thread_actions.AgentThreadActions.invoke_stream",
|
||||
side_effect=fake_invoke,
|
||||
):
|
||||
async for item in agent.invoke_stream(
|
||||
messages="message", thread=thread, on_intermediate_message=handle_stream_completion
|
||||
):
|
||||
results.append(item)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].message.content == "fake content"
|
||||
assert len(final_chat_history.messages) == 1
|
||||
assert final_chat_history.messages[0].content == "fake content"
|
||||
|
||||
|
||||
async def test_azure_ai_agent_invoke_stream_tool_message_only_goes_to_callback(ai_project_client, ai_agent_definition):
|
||||
agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition)
|
||||
thread = AsyncMock(spec=AzureAIAgentThread)
|
||||
thread.id = "test_thread_id"
|
||||
|
||||
received_callback_messages = []
|
||||
|
||||
async def async_append(msg: ChatMessageContent):
|
||||
received_callback_messages.append(msg)
|
||||
|
||||
tool_msg = ChatMessageContent(
|
||||
role=AuthorRole.ASSISTANT, content="tool call", items=[FunctionCallContent(name="ToolA", arguments="{}")]
|
||||
)
|
||||
|
||||
streamed_msg = StreamingChatMessageContent(
|
||||
role=AuthorRole.ASSISTANT, content="assistant streaming...", choice_index=0
|
||||
)
|
||||
|
||||
async def fake_invoke_stream(*args, output_messages=None, **kwargs):
|
||||
if output_messages is not None:
|
||||
output_messages.append(tool_msg)
|
||||
yield streamed_msg
|
||||
|
||||
with patch(
|
||||
"semantic_kernel.agents.azure_ai.agent_thread_actions.AgentThreadActions.invoke_stream",
|
||||
side_effect=fake_invoke_stream,
|
||||
):
|
||||
results = []
|
||||
async for item in agent.invoke_stream(messages="message", thread=thread, on_intermediate_message=async_append):
|
||||
results.append(item)
|
||||
|
||||
assert results == [AgentResponseItem(message=streamed_msg, thread=thread)]
|
||||
|
||||
assert received_callback_messages == [tool_msg]
|
||||
|
||||
|
||||
async def test_azure_ai_agent_invoke_stream_tool_message_suppressed_without_callback(
|
||||
ai_project_client, ai_agent_definition
|
||||
):
|
||||
agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition)
|
||||
thread = AsyncMock(spec=AzureAIAgentThread)
|
||||
thread.id = "test_thread_id"
|
||||
|
||||
tool_msg = ChatMessageContent(
|
||||
role=AuthorRole.ASSISTANT,
|
||||
content="tool result",
|
||||
items=[FunctionResultContent(id="test-id", name="ToolA", result="result")],
|
||||
)
|
||||
|
||||
streamed_msg = StreamingChatMessageContent(role=AuthorRole.ASSISTANT, content="assistant says hi", choice_index=0)
|
||||
|
||||
async def fake_invoke_stream(*args, output_messages=None, **kwargs):
|
||||
if output_messages is not None:
|
||||
output_messages.append(tool_msg)
|
||||
yield streamed_msg
|
||||
|
||||
with patch(
|
||||
"semantic_kernel.agents.azure_ai.agent_thread_actions.AgentThreadActions.invoke_stream",
|
||||
side_effect=fake_invoke_stream,
|
||||
):
|
||||
results = []
|
||||
async for item in agent.invoke_stream(messages="message", thread=thread):
|
||||
results.append(item)
|
||||
|
||||
# Only assistant-visible content should be yielded
|
||||
assert len(results) == 1
|
||||
assert results[0].message == streamed_msg
|
||||
|
||||
|
||||
async def test_azure_ai_agent_invoke_stream_mixed_messages(ai_project_client, ai_agent_definition):
|
||||
agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition)
|
||||
thread = AsyncMock(spec=AzureAIAgentThread)
|
||||
thread.id = "test_thread_id"
|
||||
|
||||
callback_results = []
|
||||
|
||||
async def async_append(msg: ChatMessageContent):
|
||||
callback_results.append(msg)
|
||||
|
||||
tool_msg = ChatMessageContent(
|
||||
role=AuthorRole.ASSISTANT, content="tool call", items=[FunctionCallContent(name="tool", arguments="{}")]
|
||||
)
|
||||
|
||||
text_msg = StreamingChatMessageContent(role=AuthorRole.ASSISTANT, content="streamed text", choice_index=0)
|
||||
|
||||
async def fake_invoke_stream(*args, output_messages: list = None, **kwargs):
|
||||
if output_messages is not None:
|
||||
output_messages.append(tool_msg)
|
||||
yield text_msg
|
||||
|
||||
with patch(
|
||||
"semantic_kernel.agents.azure_ai.agent_thread_actions.AgentThreadActions.invoke_stream",
|
||||
side_effect=fake_invoke_stream,
|
||||
):
|
||||
results = []
|
||||
async for item in agent.invoke_stream(messages="message", thread=thread, on_intermediate_message=async_append):
|
||||
results.append(item)
|
||||
|
||||
assert callback_results == [tool_msg]
|
||||
assert results == [AgentResponseItem(message=text_msg, thread=thread)]
|
||||
|
||||
|
||||
def test_azure_ai_agent_get_channel_keys(ai_project_client, ai_agent_definition):
|
||||
agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition)
|
||||
keys = list(agent.get_channel_keys())
|
||||
assert len(keys) >= 2
|
||||
|
||||
|
||||
async def test_azure_ai_agent_create_channel(ai_project_client, ai_agent_definition):
|
||||
agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"semantic_kernel.agents.azure_ai.agent_thread_actions.AgentThreadActions.create_thread",
|
||||
side_effect="t",
|
||||
),
|
||||
patch(
|
||||
"semantic_kernel.agents.azure_ai.azure_ai_agent.AzureAIAgentThread.create",
|
||||
new_callable=AsyncMock,
|
||||
),
|
||||
patch(
|
||||
"semantic_kernel.agents.azure_ai.azure_ai_agent.AzureAIAgentThread.id",
|
||||
new_callable=PropertyMock,
|
||||
) as mock_id,
|
||||
):
|
||||
mock_id.return_value = "mock-thread-id"
|
||||
|
||||
ch = await agent.create_channel()
|
||||
|
||||
assert isinstance(ch, AgentChannel)
|
||||
assert ch.thread_id == "mock-thread-id"
|
||||
|
||||
|
||||
def test_create_client_with_explicit_endpoint():
|
||||
credential = MagicMock(spec=AsyncTokenCredential)
|
||||
|
||||
with patch("semantic_kernel.agents.azure_ai.azure_ai_agent.AIProjectClient") as mock_client_cls:
|
||||
mock_client = MagicMock(spec=AIProjectClient)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
result = AzureAIAgent.create_client(
|
||||
credential=credential,
|
||||
endpoint="https://my-endpoint",
|
||||
extra_arg="extra_value",
|
||||
)
|
||||
|
||||
mock_client_cls.assert_called_once()
|
||||
_, kwargs = mock_client_cls.call_args
|
||||
|
||||
assert kwargs["credential"] is credential
|
||||
assert kwargs["endpoint"] == "https://my-endpoint"
|
||||
assert kwargs["extra_arg"] == "extra_value"
|
||||
assert result is mock_client
|
||||
|
||||
|
||||
def test_create_client_uses_settings_when_endpoint_none():
|
||||
credential = MagicMock(spec=AsyncTokenCredential)
|
||||
|
||||
with (
|
||||
patch("semantic_kernel.agents.azure_ai.azure_ai_agent.AzureAIAgentSettings") as mock_settings_cls,
|
||||
patch("semantic_kernel.agents.azure_ai.azure_ai_agent.AIProjectClient") as mock_client_cls,
|
||||
):
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.endpoint = "https://configured-endpoint"
|
||||
mock_settings_cls.return_value = mock_settings
|
||||
|
||||
mock_client = MagicMock(spec=AIProjectClient)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
result = AzureAIAgent.create_client(credential=credential)
|
||||
|
||||
mock_client_cls.assert_called_once()
|
||||
_, kwargs = mock_client_cls.call_args
|
||||
|
||||
assert kwargs["endpoint"] == "https://configured-endpoint"
|
||||
assert result is mock_client
|
||||
|
||||
|
||||
def test_create_client_raises_if_no_endpoint():
|
||||
credential = MagicMock(spec=AsyncTokenCredential)
|
||||
|
||||
with patch("semantic_kernel.agents.azure_ai.azure_ai_agent.AzureAIAgentSettings") as mock_settings_cls:
|
||||
mock_settings = MagicMock()
|
||||
mock_settings.endpoint = None
|
||||
mock_settings_cls.return_value = mock_settings
|
||||
|
||||
try:
|
||||
AzureAIAgent.create_client(credential=credential)
|
||||
except AgentInitializationException as e:
|
||||
assert "Azure AI endpoint" in str(e)
|
||||
else:
|
||||
assert False, "Expected AgentInitializationException to be raised"
|
||||
|
||||
|
||||
async def test_azure_ai_agent_get_response_passes_function_choice_behavior(ai_project_client, ai_agent_definition):
|
||||
agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition)
|
||||
thread = AsyncMock(spec=AzureAIAgentThread)
|
||||
fcb = FunctionChoiceBehavior.Auto()
|
||||
captured_kwargs = {}
|
||||
|
||||
async def fake_invoke(*args, **kwargs):
|
||||
captured_kwargs.update(kwargs)
|
||||
yield True, ChatMessageContent(role=AuthorRole.ASSISTANT, content="content")
|
||||
|
||||
with patch(
|
||||
"semantic_kernel.agents.azure_ai.agent_thread_actions.AgentThreadActions.invoke",
|
||||
side_effect=fake_invoke,
|
||||
):
|
||||
await agent.get_response(messages="message", thread=thread, function_choice_behavior=fcb)
|
||||
|
||||
assert captured_kwargs.get("function_choice_behavior") is fcb
|
||||
|
||||
|
||||
async def test_azure_ai_agent_invoke_passes_function_choice_behavior(ai_project_client, ai_agent_definition):
|
||||
agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition)
|
||||
thread = AsyncMock(spec=AzureAIAgentThread)
|
||||
fcb = FunctionChoiceBehavior.Auto()
|
||||
captured_kwargs = {}
|
||||
|
||||
async def fake_invoke(*args, **kwargs):
|
||||
captured_kwargs.update(kwargs)
|
||||
yield True, ChatMessageContent(role=AuthorRole.ASSISTANT, content="content")
|
||||
|
||||
with patch(
|
||||
"semantic_kernel.agents.azure_ai.agent_thread_actions.AgentThreadActions.invoke",
|
||||
side_effect=fake_invoke,
|
||||
):
|
||||
async for _ in agent.invoke(messages="message", thread=thread, function_choice_behavior=fcb):
|
||||
pass
|
||||
|
||||
assert captured_kwargs.get("function_choice_behavior") is fcb
|
||||
|
||||
|
||||
async def test_azure_ai_agent_invoke_stream_passes_function_choice_behavior(ai_project_client, ai_agent_definition):
|
||||
agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition)
|
||||
thread = AsyncMock(spec=AzureAIAgentThread)
|
||||
fcb = FunctionChoiceBehavior.Auto()
|
||||
captured_kwargs = {}
|
||||
|
||||
async def fake_invoke(*args, **kwargs):
|
||||
captured_kwargs.update(kwargs)
|
||||
yield ChatMessageContent(role=AuthorRole.ASSISTANT, content="content")
|
||||
|
||||
with patch(
|
||||
"semantic_kernel.agents.azure_ai.agent_thread_actions.AgentThreadActions.invoke_stream",
|
||||
side_effect=fake_invoke,
|
||||
):
|
||||
async for _ in agent.invoke_stream(messages="message", thread=thread, function_choice_behavior=fcb):
|
||||
pass
|
||||
|
||||
assert captured_kwargs.get("function_choice_behavior") is fcb
|
||||
|
||||
|
||||
async def test_azure_ai_agent_get_response_no_fcb_passes_none(ai_project_client, ai_agent_definition):
|
||||
agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition)
|
||||
thread = AsyncMock(spec=AzureAIAgentThread)
|
||||
captured_kwargs = {}
|
||||
|
||||
async def fake_invoke(*args, **kwargs):
|
||||
captured_kwargs.update(kwargs)
|
||||
yield True, ChatMessageContent(role=AuthorRole.ASSISTANT, content="content")
|
||||
|
||||
with patch(
|
||||
"semantic_kernel.agents.azure_ai.agent_thread_actions.AgentThreadActions.invoke",
|
||||
side_effect=fake_invoke,
|
||||
):
|
||||
await agent.get_response(messages="message", thread=thread)
|
||||
|
||||
assert captured_kwargs.get("function_choice_behavior") is None
|
||||
@@ -0,0 +1,34 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import pytest
|
||||
from pydantic import Field, SecretStr, ValidationError
|
||||
|
||||
from semantic_kernel.kernel_pydantic import KernelBaseSettings
|
||||
from semantic_kernel.utils.feature_stage_decorator import experimental
|
||||
|
||||
|
||||
@experimental
|
||||
class AzureAIAgentSettings(KernelBaseSettings):
|
||||
"""Slightly modified to ensure invalid data raises ValidationError."""
|
||||
|
||||
env_prefix = "AZURE_AI_AGENT_"
|
||||
model_deployment_name: str = Field(min_length=1)
|
||||
project_connection_string: SecretStr = Field(..., min_length=1)
|
||||
|
||||
|
||||
def test_azure_ai_agent_settings_valid():
|
||||
settings = AzureAIAgentSettings(
|
||||
model_deployment_name="test_model",
|
||||
project_connection_string="secret_value",
|
||||
)
|
||||
assert settings.model_deployment_name == "test_model"
|
||||
assert settings.project_connection_string.get_secret_value() == "secret_value"
|
||||
|
||||
|
||||
def test_azure_ai_agent_settings_invalid():
|
||||
with pytest.raises(ValidationError):
|
||||
# Should fail due to min_length=1 constraints
|
||||
AzureAIAgentSettings(
|
||||
model_deployment_name="", # empty => invalid
|
||||
project_connection_string="",
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from azure.ai.agents.models import MessageAttachment, MessageRole
|
||||
|
||||
from semantic_kernel.agents.azure_ai.azure_ai_agent_utils import AzureAIAgentUtils
|
||||
from semantic_kernel.contents.chat_message_content import ChatMessageContent
|
||||
from semantic_kernel.contents.file_reference_content import FileReferenceContent
|
||||
from semantic_kernel.contents.utils.author_role import AuthorRole
|
||||
|
||||
|
||||
def test_azure_ai_agent_utils_get_thread_messages_none():
|
||||
msgs = AzureAIAgentUtils.get_thread_messages([])
|
||||
assert msgs is None
|
||||
|
||||
|
||||
def test_azure_ai_agent_utils_get_thread_messages():
|
||||
msg1 = ChatMessageContent(role=AuthorRole.USER, content="Hello!")
|
||||
msg1.items.append(FileReferenceContent(file_id="file123"))
|
||||
results = AzureAIAgentUtils.get_thread_messages([msg1])
|
||||
assert len(results) == 1
|
||||
assert results[0].content == "Hello!"
|
||||
assert results[0].role == MessageRole.USER
|
||||
assert len(results[0].attachments) == 1
|
||||
assert isinstance(results[0].attachments[0], MessageAttachment)
|
||||
|
||||
|
||||
def test_azure_ai_agent_utils_get_attachments_empty():
|
||||
msg1 = ChatMessageContent(role=AuthorRole.USER, content="No file items")
|
||||
atts = AzureAIAgentUtils.get_attachments(msg1)
|
||||
assert atts == []
|
||||
|
||||
|
||||
def test_azure_ai_agent_utils_get_attachments_file():
|
||||
msg1 = ChatMessageContent(role=AuthorRole.USER, content="One file item")
|
||||
msg1.items.append(FileReferenceContent(file_id="file123"))
|
||||
atts = AzureAIAgentUtils.get_attachments(msg1)
|
||||
assert len(atts) == 1
|
||||
assert atts[0].file_id == "file123"
|
||||
|
||||
|
||||
def test_azure_ai_agent_utils_get_metadata():
|
||||
msg1 = ChatMessageContent(role=AuthorRole.USER, content="has meta", metadata={"k": 123})
|
||||
meta = AzureAIAgentUtils.get_metadata(msg1)
|
||||
assert meta["k"] == "123"
|
||||
|
||||
|
||||
def test_azure_ai_agent_utils_get_tool_definition():
|
||||
gen = AzureAIAgentUtils._get_tool_definition(["file_search", "code_interpreter", "non_existent"])
|
||||
# file_search & code_interpreter exist, non_existent yields nothing
|
||||
tools_list = list(gen)
|
||||
assert len(tools_list) == 2
|
||||
@@ -0,0 +1,88 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from azure.ai.projects.aio import AIProjectClient
|
||||
|
||||
from semantic_kernel.agents.azure_ai.azure_ai_agent import AzureAIAgent
|
||||
from semantic_kernel.agents.azure_ai.azure_ai_channel import AzureAIChannel
|
||||
from semantic_kernel.contents.chat_message_content import ChatMessageContent
|
||||
from semantic_kernel.contents.utils.author_role import AuthorRole
|
||||
from semantic_kernel.exceptions.agent_exceptions import AgentChatException
|
||||
|
||||
|
||||
async def test_azure_ai_channel_invoke_invalid_agent():
|
||||
channel = AzureAIChannel(AsyncMock(spec=AIProjectClient), "thread123")
|
||||
with pytest.raises(AgentChatException):
|
||||
async for _ in channel.invoke(object()):
|
||||
pass
|
||||
|
||||
|
||||
async def test_azure_ai_channel_invoke_valid_agent(ai_project_client, ai_agent_definition):
|
||||
agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition)
|
||||
|
||||
async def fake_invoke(*args, **kwargs):
|
||||
yield True, ChatMessageContent(role=AuthorRole.ASSISTANT, content="content")
|
||||
|
||||
with patch(
|
||||
"semantic_kernel.agents.azure_ai.agent_thread_actions.AgentThreadActions.invoke",
|
||||
side_effect=fake_invoke,
|
||||
):
|
||||
channel = AzureAIChannel(ai_project_client, "thread123")
|
||||
results = []
|
||||
async for is_visible, msg in channel.invoke(agent):
|
||||
results.append((is_visible, msg))
|
||||
|
||||
assert len(results) == 1
|
||||
|
||||
|
||||
async def test_azure_ai_channel_invoke_stream_valid_agent(ai_project_client, ai_agent_definition):
|
||||
agent = AzureAIAgent(client=ai_project_client, definition=ai_agent_definition)
|
||||
|
||||
async def fake_invoke(*args, **kwargs):
|
||||
yield True, ChatMessageContent(role=AuthorRole.ASSISTANT, content="content")
|
||||
|
||||
with patch(
|
||||
"semantic_kernel.agents.azure_ai.agent_thread_actions.AgentThreadActions.invoke_stream",
|
||||
side_effect=fake_invoke,
|
||||
):
|
||||
channel = AzureAIChannel(ai_project_client, "thread123")
|
||||
results = []
|
||||
async for is_visible, msg in channel.invoke_stream(agent, messages=[]):
|
||||
results.append((is_visible, msg))
|
||||
|
||||
assert len(results) == 1
|
||||
|
||||
|
||||
async def test_azure_ai_channel_get_history():
|
||||
# We need to return an async iterable, so let's do an AsyncMock returning an _async_gen
|
||||
class FakeAgentClient:
|
||||
delete_thread = AsyncMock()
|
||||
# We'll patch get_messages directly below
|
||||
|
||||
class FakeClient:
|
||||
agents = FakeAgentClient()
|
||||
|
||||
channel = AzureAIChannel(FakeClient(), "threadXYZ")
|
||||
|
||||
async def fake_get_messages(client, thread_id):
|
||||
# Must produce an async iterable
|
||||
yield ChatMessageContent(role=AuthorRole.ASSISTANT, content="Previous msg")
|
||||
|
||||
with patch(
|
||||
"semantic_kernel.agents.azure_ai.agent_thread_actions.AgentThreadActions.get_messages",
|
||||
new=fake_get_messages, # direct replacement with a coroutine
|
||||
):
|
||||
results = []
|
||||
async for item in channel.get_history():
|
||||
results.append(item)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].content == "Previous msg"
|
||||
|
||||
|
||||
# Helper for returning an async generator
|
||||
async def _async_gen(items):
|
||||
for i in items:
|
||||
yield i
|
||||
Reference in New Issue
Block a user